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
52 changes: 52 additions & 0 deletions CLI/MCPInitializeCompatibility.swift
Original file line number Diff line number Diff line change
@@ -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, options: [.sortedKeys])) ?? 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[..<newline])
pending.removeSubrange(...newline)
guard !frame.allSatisfy({ $0 == 0x20 || $0 == 0x09 || $0 == 0x0D }) else { continue }
var normalized = MCPInitializeCompatibility.normalize(frame)
normalized.append(0x0A)
frames.append(normalized)
}
guard pending.count <= Self.maximumFrameBytes else { throw Failure.frameTooLarge }
return frames
}
}
2 changes: 1 addition & 1 deletion CLI/MCPServiceLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ enum MCPServiceLoop {
return .terminate
}

if error is CancellationError {
if error is CancellationError || error is MCPStdinFrames.Failure {
return .terminate
}

Expand Down
21 changes: 4 additions & 17 deletions CLI/StdioProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ actor StdioProxy {
try setNonBlocking(fileDescriptor: stdin)

var buffer = [UInt8](repeating: 0, count: bufferSize)
var pendingData = Data()
var frames = MCPStdinFrames()

while true {
guard isRunning, let connection = self.connection else {
Expand Down Expand Up @@ -217,18 +217,11 @@ actor StdioProxy {
}

if bytesRead > 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<Void, Swift.Error>) in
connection.send(
content: pendingData,
content: frame,
completion: .contentProcessed { error in
if let error = error {
continuation.resume(throwing: error)
Expand All @@ -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
Expand Down
116 changes: 116 additions & 0 deletions CLITests/MCPInitializeCompatibilityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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 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":{}}}}}"#
.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))
}
}
8 changes: 8 additions & 0 deletions iMCP.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 = "<group>"; };
B0D1DEADBEEF00000000000B /* MCPInitializeCompatibilityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MCPInitializeCompatibilityTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
Expand Down Expand Up @@ -150,6 +154,8 @@
B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */,
B0D1DEADBEEF000000000001 /* BonjourDiscovery.swift */,
B0D1DEADBEEF000000000007 /* MCPServiceLoopTests.swift */,
B0D1DEADBEEF000000000009 /* MCPInitializeCompatibility.swift */,
B0D1DEADBEEF00000000000B /* MCPInitializeCompatibilityTests.swift */,
B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */,
);
path = CLITests;
Expand Down Expand Up @@ -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;
};
Expand Down
Loading